ブラウザアプリからAWS IoTのカスタムオーソライザーが使えなかった件
はじめに
サーバーレス開発部@大阪の岩田です。 ブラウザからAWS IoTに接続する際、カスタムオーソライザーを利用して良い感じに認証・認可できないか? ということを調べていたのですが、実現不可能だということが分かりました。
カスタムオーソライザーの設定手順と合わせて、なぜ無理だったのか? 情報共有させて頂きます。
なぜ無理だったのか?
まず最初に結論から。
後述しますが、カスタムオーソライザーを利用するには、WebSocketでAWS IoTに接続する際に、x-amz-customauthorizer-signature
等のHTTPヘッダを適切に設定する必要があります。
しかし、JavaScriptのWebSocketAPIではHTTPヘッダを操作することができません。
そのため、ブラウザからはカスタムオーソライザーを利用することが出来ないのです。
ブラウザの設定でプロキシにOWASP ZAPを指定し、OWASP ZAPの方でHTTPヘッダを調整するという力技で動作確認はできたので、一応手順もご紹介してきます。
カスタムオーソライザーの概要
カスタムオーソライザーはLambda関数を利用してAWS IoTに独自の認証・認可ロジックを実装する機能です。
- エンドポイントにカスタムオーソライザーが設定されている
- HTTP→WebSocketへのプロトコルアップグレードがリクエストされた
- HTTPリクエストにSigv4の署名が付いていない
という条件を満たした場合にカスタムオーソライザーのワークフローが実行されます。
カスタムオーソライザーのワークフローは下記の通りです。※AWSのブログから引用
API GatewayのLambdaオーソライザーと異なる点として、上記画像の(1)の部分が挙げられます。 デバイスからHTTPリクエストを送信する際に適切なHTTPヘッダを付与してやる必要があります。 必要なヘッダは以下の3つです。
- x-amz-customauthorizer-name
- <カスタムオーソライザーに設定された、トークンを設定するためのHTTPヘッダ>
- x-amz-customauthorizer-signature
それぞれの意味は下記の通りです。
x-amz-customauthorizer-name
実行するカスタムオーソライザーの名前を設定します。 エンドポイントにデフォルトのカスタムオーソライザーが設定されている場合は不要です。
<カスタムオーソライザーに設定された、トークンを設定するためのHTTPヘッダ>
独自の認証・認可サービスから払い出されたトークンを設定します。 HTTPヘッダ自体の名称はカスタムオーソライザーの設定から自由に変更可能です。
x-amz-customauthorizer-signature
トークンに対する署名を設定します。 この署名は認証・認可サービスの秘密鍵を利用して、トークンに対して作成された署名です。 秘密鍵に対応する公開鍵をカスタムオーソライザーに登録しておくことで、AWS IoTがトークンと署名の妥当性を検証します。 トークンと署名の妥当性が検証できなかった場合は、Lambdaが起動されずにエラーのレスポンスが返却されます。
カスタムオーソライザーの設定手順
それでは実際にカスタムオーソライザーを設定してみます。 今回はトークンと署名を作成するためにCognitoを利用してみました。 トークンと署名を取得するのに手頃だったのでCognitoを利用しましたが、Cognito認証とカスタムオーソライザーは全くの別物です!!混同しないようにして下さい。
Cognitoユーザープール等の作成
下記のCFnテンプレートから作成しました。
AWSTemplateFormatVersion: '2010-09-09' Description: Create Cognito User Pool Resources: CognitoUserPoolMyUserPool: Type: "AWS::Cognito::UserPool" Properties: AdminCreateUserConfig: AllowAdminCreateUserOnly: false UnusedAccountValidityDays: 7 AutoVerifiedAttributes: - email Policies: PasswordPolicy: MinimumLength: 8 RequireLowercase: false RequireNumbers: true RequireSymbols: false RequireUppercase: false Schema: - AttributeDataType: "String" DeveloperOnlyAttribute: false Mutable: true Name: "email" StringAttributeConstraints: MaxLength: "2048" MinLength: "0" Required: true - AttributeDataType: "String" DeveloperOnlyAttribute: false Mutable: true UserPoolClient: Type: AWS::Cognito::UserPoolClient Properties: ClientName: Fn::Join: - "" - - Ref: AWS::StackName - UserPoolClient GenerateSecret: false RefreshTokenValidity: 7 UserPoolId: Ref: CognitoUserPoolMyUserPool MyIdentifyPool: Type: "AWS::Cognito::IdentityPool" Properties: IdentityPoolName: MyIdentifyPool AllowUnauthenticatedIdentities: true CognitoIdentityProviders: - ClientId: Ref: UserPoolClient ProviderName: Fn::Join: - "" - - cognito-idp. - Ref: "AWS::Region" - .amazonaws.com/ - Ref: CognitoUserPoolMyUserPool ServerSideTokenCheck: false CognitoUnAuthRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Federated: - "cognito-identity.amazonaws.com" Action: - "sts:AssumeRoleWithWebIdentity" Condition: StringEquals: "cognito-identity.amazonaws.com:aud": !Ref MyIdentifyPool ForAnyValue:StringLike: "cognito-identity.amazonaws.com:amr": unauthenticated CognitoAuthRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Federated: - "cognito-identity.amazonaws.com" Action: - "sts:AssumeRoleWithWebIdentity" Condition: StringEquals: "cognito-identity.amazonaws.com:aud": !Ref MyIdentifyPool ForAnyValue:StringLike: "cognito-identity.amazonaws.com:amr": authenticated CognitoRoleMapping: Type: AWS::Cognito::IdentityPoolRoleAttachment Properties: IdentityPoolId: !Ref MyIdentifyPool Roles: authenticated: !GetAtt CognitoAuthRole.Arn unauthenticated: !GetAtt CognitoUnAuthRole.Arn Outputs: UserPoolId: Description: "User Poll ID" Value: Ref: CognitoUserPoolMyUserPool UserPoolClient: Description: 'User Pool Client ID' Value: Ref: UserPoolClient MyIdentifyPool: Description: 'Identify Pool ID' Value: Ref: MyIdentifyPool
Lambdaの作成
認証・認可のロジックを実行するLambdaを作成します。 今回はNode.js 8.10で下記のようなLambdaを作成しました。
exports.handler = async (event) => { const policy = [{ Version: "2012-10-17", Statement:[{ Action: "iot:*", Effect: "Allow", Resource: "*" }] }]; return JSON.stringify({ isAuthenticated: true, principalId: "1", disconnectAfterInSeconds: 86400, refreshAfterInSeconds: 300, policyDocuments: policy, context: { username: "hogehoge" } }); };
ポリシーとして常にiot:*
を返却していますが、実際に利用する際はユーザーに応じた適切なポリシーを返却することになります。
公開鍵の取得
AWS IoTがトークンと署名を検証するために、署名に使用した秘密鍵と対になる公開鍵が必要になります。
Cognitoの場合は、https://cognito-idp.<リージョン>.amazonaws.com/<ユーザープールID>/.well-known/jwks.json
というURLで公開鍵を取得できます。
上記のURLにアクセスすると
{"keys":[{"alg":"RS256","e":"AQAB","kid":"xxxxxxx","kty":"RSA","n":"xxxxxw","use":"sig"},{"alg":"RS256","e":"AQAB","kid":"xxxxx","kty":"RSA","n":"xxxxx","use":"sig"}]}
このようにJWK形式で公開鍵の情報が取得できます。このままだとAWS IoTで利用できないので、PEM形式に変換します。
JWKからPEMへの変換
Node.jsにjwk-to-pem
というモジュールがあるので、これを利用します。
npm install jwk-to-pem jsonwebtoken
でモジュールを導入した後、下記のコードで変換します。
const jwkToPem = require('jwk-to-pem'), jwt = require('jsonwebtoken'); const jwk1 = {"alg":"RS256","e":"AQAB","kid":"xxxxxxx","kty":"RSA","n":"xxxxxxx","use":"sig"} const pem1 = jwkToPem(jwk1) console.log(pem1) const jwk2 = {"alg":"RS256","e":"AQAB","kid":"xxxxxxx","kty":"RSA","n":"xxxxxxx","use":"sig"} const pem2 = jwkToPem(jwk2) console.log(pem2)
node jwkToPem.js
で変換を実行すると、PEM形式の公開鍵が出力されます。
-----BEGIN PUBLIC KEY----- <1つ目の公開鍵> -----END PUBLIC KEY----- -----BEGIN PUBLIC KEY----- <2つ目の公開鍵> -----END PUBLIC KEY-----
変換成功です。
カスタムオーソライザーの登録
ここからは実際にカスタムオーソライザー を設定していきます。
カスタムオーソライザーの作成
AWSマネジメントコンソールから作成していきます。 まずは「オーソライザーの作成」を選択します。
次に設定画面が開くので必要な項目を入力します。
「Lambda関数」に先ほど作成したLambdaを、「公開鍵に署名するトークン」に先ほど取得したCognitoの公開鍵を設定します。 「オーソライザーを作成じにアクティブに・・・」にチェックを入れないとオーソライザーがアクティブ化されないので注意が必要です。
必要な項目が入力できたら「オーソライザーの作成」を押下し、作成完了です。
AWS IoTにLambdaを呼び出す権限を付与
AWS IoTがLambdaを呼び出せるように権限を付与します。
aws lambda add-permission --function-name <Lambdaのfunction名> --statement-id <適当なSID> \ --action 'lambda:InvokeFunction' \ --principal iot.amazonaws.com \ --source-arn arn:aws:iot:<作成したカスタムオーソライザーのarn>
テスト
カスタムオーソライザーが設定できたのでテストしていきます。 テストにはReactとAmplifyで作った簡易なアプリを用います。
バージョンなど
検証に用いた環境の各種バージョンは下記の通りです。
- Node.js:v8.10.0
- aws-amplify:1.0.11
- aws-amplify-react:2.0.1
- base64url:3.0.0
- react:16.5.0
- react-dom:16.5.0
- react-scripts:1.1.5
- paho-mqtt:1.0.4
Reactアプリの作成
まずcreate-react-app
でアプリのひな形を作成し、npm install --save aws-amplify aws-amplify-react paho-mqtt base64url
で追加のライブラリを導入します。
ライブラリが導入できたら、App.jsを下記のように修正します。 ハイライト箇所は自分の環境に合わせて修正して下さい。
import React, { Component } from 'react'; import './App.css'; import { withAuthenticator } from 'aws-amplify-react'; import Amplify, {Auth } from 'aws-amplify'; import base64url from 'base64url' import { Client } from 'paho-mqtt' Amplify.configure({ Auth: { region: 'us-east-1', identityPoolId: 'us-east-1:xxxxxxxxxx', userPoolId: 'us-east-1_xxxxxxxxxx', userPoolWebClientId: 'xxxxxxxxx', } }) class App extends Component { constructor(props) { super(props); this.state = { messages: [], topic: '', is_subscribe: false } this.handleClick = this.handleClick.bind(this) this.handleClear = this.handleClear.bind(this) this.handleChangeTopic = this.handleChangeTopic.bind(this) this.handleReceive = this.handleReceive.bind(this) } render() { return ( <div className="App"> <input type="text" onChange={this.handleChangeTopic}></input> <button onClick={this.handleClick}>{this.state.is_subscribe? 'stop': 'subscribe'}</button> <button onClick={this.handleClear}>clear</button> <MessageList messages={this.state.messages} /> </div> ) } handleClick(topic){ Auth.currentSession().then(info=>{ const tokens = info.accessToken.jwtToken.split(".") console.log(tokens[0] + '.' + tokens[1]) console.log(base64url.toBase64(tokens[2])) }) if (this.state.is_subscribe){ console.log("stop subscribe") this.client.disconnect() this.setState({is_subscribe: false}) return } this.client = new Client('wss://xxxxxxxxxx.iot.us-east-1.amazonaws.com/mqtt', 'hogehoge') this.client.onMessageArrived = this.handleReceive this.client.connect({ useSSL: true, mqttVersion: 3, onSuccess: () => { console.log('connect success!!') this.client.subscribe(this.state.topic) }, onFailure: () => console.log('connect failure...') }) this.setState({is_subscribe: true}) } handleClear(event){ this.setState({messages: []}) } handleChangeTopic(event) { this.setState({topic: event.target.value}) } handleReceive(msg){ const messages = this.state.messages messages.push(msg.payloadString) this.setState({messages: messages}) } } class MessageList extends React.Component { render() { let id = 1 return ( <ul> {this.props.messages.map(message => { id += 1 return ( <li key={id}> <div> {message} </div> </li> ) })} </ul> ) } } export default withAuthenticator(App);
ポイントとしては、ボタンクリック時の処理
Auth.currentSession().then(info=>{ const tokens = info.accessToken.jwtToken.split(".") console.log(tokens[0] + '.' + tokens[1]) console.log(base64url.toBase64(tokens[2])) })
の部分でCognitoのアクセストークンからJWTトークンを取り出し、
- ヘッダー+クレーム
- 署名をBASE64形式に変換した値
をそれぞれコンソールに出力しています。 出力した値は後ほどカスタムオーソライザーを呼び出す際に利用します。
AWS CLiを使ってテスト
npm start
でアプリを起動すると、サインイン画面が表示されるので、サインアップ&サインインします。
サインイン後に表示された画面でsubscribeボタンを押下すると、コンソールにAWS IoTに送るべきトークンと署名が出力されるのでコピーします。
一緒にエラーも出力されていますが、認証エラーでAWS IoTに接続できていないことが原因です。 カスタムオーソライザーが正しく機能していそうです。
コピーした値を使用し、AWS Cliで下記のコマンドを叩いて動作確認します。
aws iot test-invoke-authorizer --authorizer-name <作成したカスタムオーソライザーの名前> --token <出力されたトークン> --token-signature <出力された署名>
下記のようなポリシードキュメントが返却されれば成功です。
{ "isAuthenticated": true, "principalId": "1", "policyDocuments": [ "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":\"iot:*\",\"Effect\":\"Allow\",\"Resource\":\"*\"}]}" ], "refreshAfterInSeconds": 300, "disconnectAfterInSeconds": 86400 }
Reactアプリからテスト
ここまでで、カスタムオーソライザーがうまく設定できていることが確認できました。 いざ、Reactアプリを実装していこうと思ったのですが、、、 冒頭で説明したようにJavaScriptからWebSocketのHTTPヘッダが操作できないことが判明しました。
ここまで来て諦められないので、OWASP ZAPを使って力技で動作確認してみます。 ブラウザのプロキシにOWASP ZAPを指定し、AWS IoTエンドポイントとの通信に割り込み、手動でHTTPヘッダを挿入してやります。 挿入するHTTPヘッダは下記の通りです。
x-amz-customauthorizer-name: <作成したカスタムオーソライザーの名前> x-amz-customauthorizer-signature: <先ほどコンソールに出力された署名> my-token: <先ほどコンソールに出力されたトークン>
ヘッダ名のmy-token
はカスタムオーソライザーの設定「トークンキー名」と揃えます。
これまでの手順が正しく実施できて入れば、無理矢理ですがブラウザからカスタムオーソライザーで認証・認可してSubscribeすることができます。 マネジメントコンソールからブラウザがSubscribeしているトピックに対してPublishしてみます。
ブラウザ側です。
(無理矢理ですが)無事にSubscribeできています!!
まとめ
AWS IoTのカスタムオーソライザーについて見てきました。 カスタムオーソライザー自体は自由度が広がって便利な機能だと思うのですが、ブラウザで利用できないのは少し残念ですね。 モバイルアプリを作るスキルが無いので試せませんでしたが、モバイルアプリからだと便利に使えるかもしれません。
もしカスタムオーソライザーとブラウザアプリを組み合わせた構成を検討されている方が入ればご注意下さい!!